详细总结组合排列的十余种算法实现 您所在的位置:网站首页 golang排列组合 lib 详细总结组合排列的十余种算法实现

详细总结组合排列的十余种算法实现

2024-06-26 10:34| 来源: 网络整理| 查看: 265

 

目录

•写在前面

•问题引入

•暴力枚举

循环枚举

递归枚举

回溯枚举

•深度优先搜索

前序遍历

中序遍历

后序遍历

•字典序

•二进制位运算

•带重复数字

•总结

•写在前面

排列组合的问题,如果没有合适的算法去解决,时间复杂度会相当的大,毕竟阶乘的时间复杂度不仅让人头大,也让他计算机欲罢不能,而且我们遇到排列组合的相关问题的概率相当的大,所以非常有必要掌握排列组合相关的算法,碰到很多问题,我们心里就有些底气了。我这里例举几种算法,其中想要特别强调二进制的相关解法,非常有趣。

•问题引入

我们把实际问题抽象一下,就是从一个集合中取出任意元素,形成唯一的组合。如 [a,b,c] 可组合为 [a]、[b]、[c]、[ab]、[bc]、[ac]、[abc]。我们既然说的是排列组合,当然就有区分这个集合里面,是否存在重复的元素的问题,如 [a,a,b,c] 可组合为 [a]、[b]、[c]、[ab]、[aa]、[bc]、[ac]、[abc]、[aab]等等。针对是否存在重复的元素问题,我们自然需要不同的解决方案,不过有些方法思路存在共性,接下来我们以不同算法为基础,结合不同的问题进行讲解,完成思路的理解(我们的重点是思路,有了思路,实现代码的时候就简单多了)。这里我们抛出一个问题,即如下

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。 说明:解集不能包含重复的子集。 输入: nums = [1,2,3] 输出: [   [3],   [1],   [2],   [1,2,3],   [1,3],   [2,3],   [1,2],   [] ]

我们先按照不含重复元素来进行思考,然后附上含重复元素的思路。

•暴力枚举

对于这种排列组合的问题,首先想到的当然是穷举。由于排列的要求较少,实现更简单一些,如果我先找出所有排列,再剔除由于位置不同而重复的元素,即可实现需求。假设需要从 [A B C D E] 五个元素中取出所有组合,那么我们先找出所有元素的全排列,然后再将类似 [A B] 和 [B A] 两种集合去重即可。当然,我们可以在枚举的时候进行一些剪枝,即在枚举个过程中进行一些判断,也可以当做一种优化。总结就是,逐个枚举,空集的幂集只有空集,每增加一个元素,让之前幂集中的每个集合,追加这个元素,就是新增的子集。还是不知道啥意思?没关系,我么来看思路和代码实现,这里我们分为两种枚举方式。

循环枚举

循环枚举的思路其实特别简单(暴力的思路就是人类的直觉,思路当然简单啦,哈哈哈),我们先创建结果集(res),然后往结果集里面添加一个空集,现在完成结果集的初始化之后(初始化的结果集中只有一个空集),我们开始在集合(nums)中开始循环遍历所有的元素,每次遍历一个元素,我们就把这个元素添加在当前结果集的每一个子集后,并形成新的子集,添加到结果集中,思路非常的简单暴力,代码吐下:

public static List enumerate(int[] nums) { List res = new ArrayList(); res.add(new ArrayList()); for (Integer n : nums) { int size = res.size(); for (int i = 0; i < size; i++) { List newSub = new ArrayList(res.get(i)); newSub.add(n); res.add(newSub); } } return res; } 递归枚举

递归枚举的总体思路和循环枚举一致,只不过我们的实现方式是使用递归,代码的变化其实就是把循环枚举里面的最外面那层遍历集合(nums)的循环,使用递归来代替,很简单的,看代码你就明白了。

public static void recursion(int[] nums, int i, List res) { if (i >= nums.length) return; int size = res.size(); for (int j = 0; j < size; j++) { List newSub = new ArrayList(res.get(j)); newSub.add(nums[i]); res.add(newSub); } recursion(nums, i + 1, res); } 回溯枚举

回溯枚举就和上面的两种枚举思路有点不一样了,这种枚举思路就好像是我们人的思路,进行一个一个拼凑,只要符合我们子集的条件,就将这个子集添加进结果集,代码如下:

public static void backtrack(int[] nums, int i, List sub, List res) { for (int j = i; j < nums.length; j++) { if (j > i && nums[j] == nums[j - 1]) continue; sub.add(nums[j]); res.add(new ArrayList(sub)); backtrack(nums, j + 1, sub, res); sub.remove(sub.size() - 1); } } •深度优先搜索

集合中每个元素其实就两种状态,就是选和不选,我们通过这两种状态,可以构成了一个满二叉状态树,比如,左子树是不选,右子树是选,从根节点、到叶子节点的所有路径,构成了所有子集。可以有前序、中序、后序的不同写法,结果的顺序不一样。本质上,其实是比较完整的中序遍历。我这样光说,可能比较抽象,我这里通过画的一张中序遍历的图进行讲解,如下。

前序遍历 public static void preOrder(int[] nums, int i, ArrayList subset, List res) { if (i >= nums.length) return; subset = new ArrayList(subset); res.add(subset); preOrder(nums, i + 1, subset, res); subset.add(nums[i]); preOrder(nums, i + 1, subset, res); } 中序遍历 public static void inOrder(int[] nums, int i, ArrayList subset, List res) { if (i >= nums.length) return; subset = new ArrayList(subset); inOrder(nums, i + 1, subset, res); subset.add(nums[i]); res.add(subset); inOrder(nums, i + 1, subset, res); } 后序遍历 public static void postOrder(int[] nums, int i, ArrayList subset, List res) { if (i >= nums.length) return; subset = new ArrayList(subset); postOrder(nums, i + 1, subset, res); subset.add(nums[i]); postOrder(nums, i + 1, subset, res); res.add(subset); } •字典序

如果你足够理解了前面的思路,其实你应该会发现前面本质思路可以分为两类,暴力、递归、回溯一类,以及深度优先搜索一类,分类的依据是什么呢?其实就是思考的角度不一样,前面的那一类,我们思考的对象是每个子集,我们对每个子集进行相关算法的实现,而深度优先搜索开始,我们将关注的点放在集合(nums)中的每个元素的状态,我们集合中的每个元素在子集中只有两个状态,也就是存在和不存在。现在我们将关注的点转到了元素的状态上,即01状态,上面的深度优先搜索的实现只是一个过渡,本质上和递归等效率差不了多少,因为01状态是二进制的天下,我们自然使用二进制来代替,效率很高。利用二进制的思想去解决这个问题,就很简单了,值得一提的是,我们在使用二进制思想解决问题的时候,并不一定说使用位运算,而这里要讲的字典序,就不适用位运算来实现二进制思路。为了更好的理解思路,我们暂时先将问题简化为:

给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。 输入: n = 4, k = 2 输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]

算法其实非常的直观,不过有一个值得注意的是,我们在子集的最后需要放一个标志位(也就是所说的哨兵),在每次循环的时候,我们只要找到nums中的第一个满足 nums[j] + 1 != nums[j + 1]的元素,并将其加一nums[j]++ 以转到下一个组合。这种思想代替了位运算,完成了二进制的实现

public List combine(int n, int k) { LinkedList nums = new LinkedList(); for(int i = 1; i < k + 1; ++i) nums.add(i); nums.add(n + 1); List output = new ArrayList(); int j = 0; while (j < k) { output.add(new LinkedList(nums.subList(0, k))); j = 0; while ((j < k) && (nums.get(j + 1) == nums.get(j) + 1)) nums.set(j, j++ + 1); nums.set(j, nums.get(j) + 1); } return output; } •二进制位运算

很多算法都能通过位运算巧秒地解决,其优势主要有两点:一者位运算在计算机中执行效率超高,再者由于位运算语义简单,算法大多直指本质。组合算法也能通过位运算实现,而且代码实现之后,简直令人回味无穷。我们将问题回归到最开始的问题,现在我们假设,从 M 个元素中取任意个元素形成组合,组合内元素不能重复、元素位置无关。我们使用状态的思想,对于每个元素来说,要么被放进组合,要么不放进组合。每个元素都有这么两种状态。我们这里举个例子,如果从 5 个元素中任意取 N 个元素形成组合的话,用二进制位来表示每个元素是否被放到组合里,就是:

每种组合都可以拆解为 N 个二进制位的表达形式,而每个二进制组合同时代表着一个十进制数字,所以每个十进制数字都就能代表着一种组合。十进制数字的数目我们很简单就能算出来,从00000... 到 11111... 一共有种,排除掉全都不被放进组合这种可能,结果有几种。

public List combination(String[] m) { List result = new ArrayList(); for (int i = 1; i < Math.pow(2, m.length) - 1; i++) { List eligibleCollections = new ArrayList(); for (int j = 0; j < m.length; j++) { if ((i & (int) Math.pow(2, j)) == Math.pow(2, j)) { eligibleCollections.add(m[j]); } } result.add(eligibleCollections); } return result; }

当然,还想更秀操作一点,我们在第一和二层循环那里,使用位移运算,来完成与运算,本质是一样的,代码如下

public static List binaryBit(int[] nums) { List res = new ArrayList(); for (int i = 0; i < (1 > j) & 1) == 1) sub.add(nums[j]); res.add(sub); } return res; }

当然,如果我们还有一种稍微绕一点点的二进制实现方式,就是数1的个数,这种思路有点绕,不过不管怎么样都还是二进制的一种实现方式,带着二进制的特色,我这里就大概的提一下数二进制中1的个数的实现方式,具体的问题解决代码,感兴趣的可以自己去实现以下,数二进制中1的个数的代码如下,想要关于数二进制的个数的其他算法,可以看我另一篇文章

int BitCount1(unsigned int n) { unsigned int c =0 ; // 计数器 for (c =0; n; n >>=1) // 循环移位 c += n &1 ; // 如果当前位是1,则计数器加1 return c ; } •带重复数字

我们在集合中带有重复数字,这样的集合和不带重复数字有什么区别呢?我们按照这个思路,其实可以使用不重复数字的求解方式,在求解带重复数字集合的过程中,进行去重即可。有了这种思路,我们去设计算法其实就不难了,在暴力、递归、回溯、深搜的方法中,我们很容易的将算法进行改进,我在这里就不多提了,这里要说的是二进制的算法改进。你仔细想一下我们怎么样才能在二进制的算法中进行相应的去重,这其实不容易想到的。

我们来假设nums中有[1,2,3],那么它的结果集以及对应的二进制形如下面这这样

1 2 3 0 0 0 -> [ ] 0 0 1 -> [ 3] 0 1 0 -> [ 2 ] 0 1 1 -> [ 2 3] 1 0 0 -> [1 ] 1 0 1 -> [1 3] 1 1 0 -> [1 2 ] 1 1 1 -> [1 2 3]

但是如果有了重复数字,很明显就行不通了。例如对于 nums = [ 1 2 2 2 3 ]。

1 2 2 2 3 0 1 1 0 0 -> [ 2 2 ] 0 1 0 1 0 -> [ 2 2 ] 0 0 1 1 0 -> [ 2 2 ]

上边三个数产生的数组重复的了。三个中我们只取其中 1 个,取哪个呢?我们仔细想一下应该好想到,就是我们只要去重复数字的开头的序列就可以了,比如重复了三个2,那么我们只要分别取一个2开头即“2”,两个2开头即“22”以及三个2开头即“222”就可以了,这样就可以避免重复,更形象的解释,我们举个五个2的例子,然后例举出他们不重复的序列,像下面这样

2 2 2 2 2 1 0 0 0 0 -> [ 2 ] 1 1 0 0 0 -> [ 2 2 ] 1 1 1 0 0 -> [ 2 2 2 ] 1 1 1 1 0 -> [ 2 2 2 2 ] 1 1 1 1 1 -> [ 2 2 2 2 2 ]

那么这个时候,我们就可以将问题转成,我们怎么去取不同个数的2在一起,其实仔细思考也不难理解,我们只要对二进制稍微研究一下就知道了,我们先拿两个2来举例子,在五位二进制中,有两个2开头的二进制序列有哪些?

2 2 2 2 2 1 1 0 0 0 -> [ 2 2 ] 1 0 1 0 0 -> [ 2 2 ] 0 1 1 0 0 -> [ 2 2 ] 0 1 0 1 0 -> [ 2 2 ] 0 0 0 1 1 -> [ 2 2 ]

上面所有两个2的情况,我们只需要去其中一种,那么我们应该取哪种?怎么取呢?这个时候我们观察一下他们是否存在不同点,而且其中存在一个和其他情况都不同的形式,我们来看一下出现了重复数字,并且当前是 1 的前一个的二进位。

对于 1 1 0 0 0 ,是 1。 对于 1 0 1 0 0 , 是 0。 对于 0 1 1 0 0 ,是 0。 对于 0 1 0 1 0 ,是 0。 对于 0 0 0 1 1 ,是 0。

可以看到只有第一种情况对应的是 1 ,其他情况都是 0。其实除去从开头是连续的 1 的话,就是两种情况。第一种就是,占据了开头,类似于这种 10...1....。第二种就是,没有占据开头,类似于这种 0...1...。这两种情况,除了第一位,其他位的 1 的前边一定是 0。所以的话,我们的条件是看出现了重复数字,并且当前位是 1 的前一个的二进位。有了这种思路,我们只需要在不重复的代码中,进行一个判断就可以了,代码如下

public static List binaryBit(int[] nums) { List res = new ArrayList(); for (int i = 0; i < (1 > j) & 1) == 1) { if(j>0&&nums[j]==nums[j-1]&&(i>>(j-1)&1)==0){ illegal=true; break; }else{ sub.add(nums[j]); } } if(!illegal){ res.add(sub); } } return res; } •总结

二进制是真的很美妙,我们的很多问题都可以通过二进制来解决,所以我们需要慢慢适应并融入二进制的世界,思考问题的角度和 思路也将变得更加开阔。



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有